﻿/*
 *
 * The contents of this file are subject to the Mozilla Public License Version 1.0 (the "License"); 
 * you may not use this file except in compliance with the License.
 * 
 * You may obtain a copy of the License at http://www.mozilla.org/MPL/
 * 
 * Software distributed under the License is distributed on an "AS IS" basis, 
 * WITHOUT WARRANTY OF ANY KIND, either express or implied.
 * 
 * See the License for the specific language governing rights and limitations under the License.
*/

using System;
using System.Text;
using System.Security.Cryptography;
using System.Web;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;

namespace FBConnectAuth
{
	public class FBConnectAuthentication
	{
		private readonly string appSecret;
		private readonly string apiKey;
		private readonly string appId;

		private static readonly string GRAPHAPI_SIG_SUBPART_NAME = "sig";
		private static readonly string GRAPHAPI_ACCESSTOKEN_SUBPART_NAME = "access_token";
		private static readonly string GRAPHAPI_EXPIRES_SUBPART_NAME = "expires";
		private static readonly string GRAPHAPI_SESSIONSECRET_SUBPART_NAME = "secret";
		private static readonly string GRAPHAPI_SESSIONKEY_SUBPART_NAME = "session_key";
		private static readonly string GRAPHAPI_UID_SUBPART_NAME = "uid";

		/// <summary>
		/// Initiliazes an instance of <see cref="FBConnectAuthentication"/>.
		/// </summary>
		/// <param name="apiKey">The apiKey of your Facebook application (if you are using the old "FeatureLoader" JavaScript, or the appId of your FB app if you are using the newer Javascript Graph API released in 2010).</param>
		/// <param name="appSecret">Your Facebook application's "secret"</param>
		public FBConnectAuthentication(string apiKeyOrAppId, string appSecret)
		{
			if (String.IsNullOrEmpty(apiKeyOrAppId))
			{
				throw new ArgumentException("apiKeyOrAppId cannot be null or empty", "apiKeyOrAppId");
			}
			if (String.IsNullOrEmpty(appSecret))
			{
				throw new ArgumentException("appSecret cannot be null or empty", "appSecret");
			}

			this.appSecret = appSecret;
			this.apiKey = apiKeyOrAppId.ToLowerInvariant();
			this.appId = this.apiKey; //the graph api uses the appId not the api key
		}

		/// <summary>
		/// Validates that the request came from a validated Facebook user using the cookies from the current <see cref="HttpRequest"/>.
		/// </summary>
		/// <returns>A <see cref="ValidationState"/> value, indicating whether the request was valid.</returns>
		/// <exception cref="InvalidOperationException">Thrown when HttpContext.Current is null</exception>
		public ValidationState Validate()
		{
			if (HttpContext.Current == null)
			{
				throw new InvalidOperationException("This method can only be used from within an ASP.NET web request.");
			}
			return Validate(HttpContext.Current.Request.Cookies);
		}

		/// <summary>
		/// Validates that the request came from a validated Facebook user.
		/// </summary>
		/// <param name="cookies">A collection of cookies containing the Facebook Connect auth cookies.</param>
		/// <returns>A <see cref="ValidationState"/> value, indicating whether the request was valid.</returns>
		/// <exception cref="ArgumentNullException">Thrown when the <paramref name="cookies"/> parameter is null.</exception>
		public ValidationState Validate(HttpCookieCollection cookies)
		{
			if (cookies == null)
			{
				throw new ArgumentNullException("cookies");
			}

			// See http://wiki.developers.facebook.com/index.php/Verifying_The_Signature for more info
			string signature = GetOldStyleSignatureFromCookies(cookies);

			if (signature != null)
			{
				return validateOldStyleCookies(cookies, signature);
			}
			else
			{
				// no old style cookies, lets try for the new Graph API cookie
				return validateGraphApiCookie(cookies);
			}

		}

		private ValidationState validateGraphApiCookie(HttpCookieCollection cookies)
		{
			foreach (string cookieName in cookies)
			{
				if (cookieName == "fbs_" + appId)
				{
					NameValueCollection subParts = convertFbGraphApiCookieToNameValueCollection(cookies[cookieName]);

					string signature = subParts[GRAPHAPI_SIG_SUBPART_NAME];					
					if (String.IsNullOrEmpty(signature)) return ValidationState.NoSignatureFound;
					
					StringBuilder payload = new StringBuilder();
					foreach (String s in subParts.AllKeys)
					{
						if (s != GRAPHAPI_SIG_SUBPART_NAME)
						{
							payload.AppendFormat("{0}={1}", s, subParts[s]);
						}
					}
					payload.Append(appSecret);
					bool isSigValid = doesSignatureMatch(signature, payload.ToString());
					return isSigValid ? ValidationState.Valid : ValidationState.InvalidSignature;
				}
			}
			return ValidationState.NoSignatureFound;
		}

		private static NameValueCollection convertFbGraphApiCookieToNameValueCollection(HttpCookie fbCookie)
		{
			string rawValue = fbCookie.Value;

			NameValueCollection subParts = HttpUtility.ParseQueryString(rawValue.Replace("\"", ""));
			return subParts;
		}

		private ValidationState validateOldStyleCookies(HttpCookieCollection cookies, string signature)
		{
			SortedList<string, string> sortedCookieValues = ExtractAndSortFBCookies(cookies);

			var sb = new StringBuilder();
			foreach (KeyValuePair<string, string> pair in sortedCookieValues)
			{

				sb.AppendFormat("{0}={1}", pair.Key, pair.Value);
			}

			sb.Append(appSecret);
			string stringToHash = sb.ToString();

			bool isSigValid = doesSignatureMatch(signature, stringToHash);
			return isSigValid ? ValidationState.Valid : ValidationState.InvalidSignature;
		}

		private static bool doesSignatureMatch(string signature, string stringToHash)
		{
			StringBuilder computedHash = new StringBuilder();
			byte[] hash = MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(stringToHash));
			foreach (byte b in hash)
			{
				computedHash.AppendFormat("{0:x2}", b);
			}

			bool isSigValid = computedHash.ToString().ToLowerInvariant() == signature.ToLowerInvariant();
			return isSigValid;
		}

		private SortedList<string, string> ExtractAndSortFBCookies(HttpCookieCollection cookies)
		{
			var result = new SortedList<string, string>();
			string cookiePrefix = apiKey + "_";
			foreach (string cookieName in cookies)
			{
				if (cookieName.StartsWith(cookiePrefix))
				{
					var cookie = cookies[cookieName];
					result.Add(cookie.Name.Substring(cookiePrefix.Length), cookie.Value);
				}
			}
			return result;
		}



		private string GetOldStyleSignatureFromCookies(HttpCookieCollection cookies)
		{
			var sigCookie = cookies[apiKey];
			return sigCookie != null ? sigCookie.Value : null;
		}

		/// <summary>
		/// Gets a FB Connect session from the values in the cookies from the current <see cref="HttpRequest"/>.
		/// </summary>
		/// <returns>A FB Connect Session object</returns>
		/// <exception cref="ArgumentNullException">Thrown when <paramref name="cookies"/> is null.</exception>
		/// <exception cref="FBConnectAuthenticationException">Thrown when the signature cannot be validated.</exception>
		/// <exception cref="ArgumentOutOfRangeException">Thrown when the expires cookie value is not a valid integer.</exception>
		/// <exception cref="InvalidOperationException">Thrown when HttpContext.Current is null</exception>
		public FBConnectSession GetSession()
		{
			if (HttpContext.Current == null)
			{
				throw new InvalidOperationException("This method can only be used from within an ASP.NET web request.");
			}
			return GetSession(HttpContext.Current.Request.Cookies);
		}

		/// <summary>
		/// Gets a FB Connect session from the values in the <paramref name="cookies"/>.
		/// </summary>
		/// <param name="cookies">Cookies containing FB Connect session information</param>
		/// <returns>A FB Connect Session object</returns>
		/// <exception cref="ArgumentNullException">Thrown when <paramref name="cookies"/> is null.</exception>
		/// <exception cref="FBConnectAuthenticationException">Thrown when the signature cannot be validated.</exception>
		/// <exception cref="ArgumentOutOfRangeException">Thrown when the expires cookie value is not a valid integer.</exception>
		public FBConnectSession GetSession(HttpCookieCollection cookies)
		{
			if (cookies == null)
			{
				throw new ArgumentNullException("cookies");
			}

			ValidationState validationState = Validate(cookies);
			if (validationState != ValidationState.Valid)
			{
				throw new FBConnectAuthenticationException("Cannot create FB Connect session.", validationState);
			}

			return CreateFBSessionFromCookies(cookies);
		}

		private FBConnectSession CreateFBSessionFromCookies(HttpCookieCollection cookies)
		{
			bool isNewStyleCookiePresent = cookies["fbs_" + appId] != null;

			return isNewStyleCookiePresent ? createSessionFromGraphApiCookies(cookies) : createSessionFromOldStyleCookies(cookies);
		}

		private FBConnectSession createSessionFromGraphApiCookies(HttpCookieCollection cookies)
		{
			string appId = apiKey; //Graph API uses appId not apiKey
			HttpCookie cookie = cookies["fbs_" + appId];
			if (cookie == null) throw new FBConnectAuthenticationException("Cannot retrieve graph api cookie, and there's not much I can do about it.");
			NameValueCollection nvc = convertFbGraphApiCookieToNameValueCollection(cookie);
			
			int expires;
			if (!Int32.TryParse(nvc[GRAPHAPI_EXPIRES_SUBPART_NAME], out expires))
			{
				throw new ArgumentOutOfRangeException("cookies", "The value of 'expires' cookies is not a valid integer.");
			}
			return new FBConnectSession(expires,
										nvc[GRAPHAPI_SESSIONKEY_SUBPART_NAME],
										nvc[GRAPHAPI_SESSIONSECRET_SUBPART_NAME],
										nvc[GRAPHAPI_UID_SUBPART_NAME],
										nvc[GRAPHAPI_ACCESSTOKEN_SUBPART_NAME]);
		}

		private FBConnectSession createSessionFromOldStyleCookies(HttpCookieCollection cookies)
		{
			string cookiePrefix = apiKey + "_";
			int expires;
			if (!Int32.TryParse(cookies[cookiePrefix + "expires"].Value, out expires))
			{
				throw new ArgumentOutOfRangeException("cookies", "The value of 'expires' cookies is not a valid integer.");
			}
			string userId = cookies[cookiePrefix + "user"].Value;
			string sessionKey = cookies[cookiePrefix + "session_key"].Value;
			string sessionSecret = cookies[cookiePrefix + "ss"].Value;
			return new FBConnectSession(expires, sessionKey, sessionSecret, userId);
		}
	}
}
